iT邦幫忙

2024 iThome 鐵人賽

DAY 6
0
JavaScript

Don't make JavaScript Just Surpise系列 第 6

物件(object)與複製行為

  • 分享至 

  • xImage
  •  

物件 object 是複合型別 Complex Type中的主要被提起和想到的。
可能很多人都有聽過一句話:在 JS 中,只要不是原始型別,其他都是物件。

陣列 array,函式 function 這些都是物件。
前面的篇幅或多或少有帶到物件的概念(像是比較原始型別和複合型別的時候),但我們再來定義一次:什麼是物件?

物件是由一組或多組 鍵 - 值對(key - value pair)組成的資料結構。

鍵值對是什麼意思?我們來看實際的程式碼:

//兩種宣告方式
let obj1 = new Object();
obj1.key = value;

let obj2 = {
    key : value,
};

通常我們會把 鍵 稱作該物件的屬性 property
上面的兩種宣告方式,一般多用第二種,第二種宣告出來的方式能夠便於進行多組鍵值的宣告,除此之外,兩者宣告出來的物件是等價的。

物件的鍵只會是字串(若非 symbol 情況下會強制轉型為字串),symbol。

從物件取值我們會用像這樣的方式:

let obj = {
    value1 : 123,
    'value1' : 234,
    "this is value2" : 456,
}

console.log(obj.value1);//234
console.log(obj['value1']);//234
console.log(obj["this is value2"]);//456

使用 [] 來將鍵包裹起來,或是直接使用 . 來訪問。
可以看到,obj 宣告中 value1 和 'value1' 兩者是被視為同一鍵,而在印出 console 時因為重複宣告而採用後宣告的 234,印證了我們上面說的對物件鍵的字串轉型。

使用 []. 在大多情況下並沒有區別,所有對鍵的訪問都默認為字串類型的鍵,訪問時不能寫成 obj.'value1',會直接收到編譯器報錯。而有區別的情況在於鍵的名稱帶有空格時必須使用 [] 來做訪問,避免編譯解讀時解讀成其他的語法。

鍵的默認型別甚至作用於純數字的鍵上,要盡量避免這種純數字鍵,可能會看起來很像陣列導致混淆。

let obj = {
    1:123,
}

console.log(obj[1]);
console.log(obj['1']);
//合法的,兩個都會印出 123

使用 [] 還有一個好處:動態決定屬性名稱。

let obj = {
    valueA:'abc',
    value1:123,
}
let keyA = 'A';
let keyOne = '1';
console.log(obj['value'+ keyA]);//abc
console.log(obj['value'+ keyOne]);//123

[] 中,你可以合法的使用變數作為鍵值,字串相加等等操作,能夠提供更大的靈活性。

屬性 v.s. 函式 v.s. 方法

插入一個討論,我們來定義一下這幾個詞,先搞懂他們的不同,我們再繼續往下走。
屬性(property):物件上的其中一組鍵值對的鍵名稱
函式(function):泛指所有 function()
方法(method):特指物件上的鍵值對中的屬性,該屬性的值恰巧是 function 時,我們會稱其為方法

function foo(){   
}
let obj = {
    key : value;
    bar : foo
}

我們稱 foo()函式barobj 上的一個方法,而 key 則是 obj 上的一個 屬性
習慣上來說,函式是一個更廣義的定義,方法特指物件上的函式,用於陳述或實作物件的特定行為表現。

陣列(array)

陣列在某些語言中是一種獨立的型別,在 JS 中,他也是陣列,但絕對值得獨立出來講。

let arr = ['a',123,true];
console.log(arr[0]);//a
console.log(arr[1]);//123
console.log(arr[2]);//true

陣列的宣告透過 [] 進行,但陣列中的各個值得型別不需要一樣,如同上方例子同時有字串,數字和布林值。
陣列的訪問和物件相仿,但鍵值在陣列中我們慣於稱其為索引值(index),而在 JS 中的陣列為 0-index,意味著陣列的第一個元素的索引值必定為 0。所有索引直接為正整數(除了 0 以外)。

記得,陣列也是物件的一種,所以你當然可以對他新增任意的鍵值組合。
但一般並不鼓勵,容易造成語意不清。且透過這種方式新增的鍵值,並不會影響一個陣列的 .length 所回傳的值。

let arr = [123];
arr.attr1 = 456;
console.log(arr.length);//1

arr['2'] = 567
console.log(arr.length);//3
console.log(arr);//123, undefined, 567

如果你的屬性恰巧定義為一個符合正整數的鍵,則要小心會直接改到陣列的對應索引值的元素,他會被解析為你要針對該索引位置的訪問。
如同上面的例子對 '2' 鍵的訪問直接造成了 undefined 的填充,也改變了陣列的長度。

淺拷貝與深拷貝(Shallow Copy and Deep Copy)

複製一個物件在進行修改,是物件操作中相當基礎的一項行為。
關於複製,我們可以依據行為分成兩種:淺拷貝與深拷貝。

深淺拷貝關係到的是賦值時傳值或傳址的特性,在 Day 3 的傳遞方法段落中有討論到。

let b = '123';
let a = {
    k1:123,
    k2:'123',
    k3:b,
}
let c = a;
c.k1 = 246;
c.k2 = 246;
c.k3 = 246;
console.log(a.k1, a.k2, a.k3);//246 246 246
console.log(c.k1, c.k2, c.k3);//246 246 246
console.log(a==c, a===c);//true true

在上面的程式碼裡,c 是透過指向 a 複製出來的物件,結果對於 c 的修改,完全反應到了 a 身上。
實際上,c 只是將該變數的儲存位址指向了 a 指向的相同位址,該位址再指向三個屬性。
因為修改的是同一個位址裡的東西,賦予行為實際上是位址的複製,這種情況我們稱作淺拷貝

深拷貝就是相反概念:賦予時給的是一個同值但不同址的內容,傳的是值。如原始型別:

let a = 'foo';
let b = a;
b = 'bar';
console.log(a, b);//"foo" "bar"

在這個例子中,b 通過複製了 a 指向位址儲存的「值」,儲存於新的位址。
因此 a 和 b 雖然值相同,但址不同,更改 b 的時候,並不會影響的 a 的值,因為修改的位址不同。
所有原始型別在使用賦值賦予的時候皆會是深拷貝。

JSON.stringify & JSON.parse

在 JS 裡,我們也想會有想要對物件這種複合型別進行深拷貝的時候,那麼最簡單的方式如下:

let a = {k1 : 123};
let b = JSON.parse(JSON.stringify(a));
b.k1 = 456;
console.log(a.k1, b.k1);//123, 456 

透過內建的 JSON 序列化與反序列化函式,先攤平成字串,再解析為物件。
如上所示,對 b.k1 的修改現在並不影響 a 了。

使用這個方法有幾個缺點:

  1. 僅能對合法的 JSON 對象使用(最外圍為 []{}),剖析建立物件時也只會是合法的 JSON 對象,也無法存在處理循環引用的物件
    let objA = {};
    let objB = {};
    objA.key1 = objB;
    objB.key1 = objA;
    console.log(JSON.stringify(objA));
    // Uncaught TypeError: Converting circular structure to JSON
    
  2. 序列化時,對部分的值會進行忽視,如物件上的 undefined, function, symbol,同時該鍵也不會在新的物件裡出現
    let obj = {
        stringType: "123",
        numberType: 123,
        nullType: null,
        undefinedType: undefined,
        booleanType: true,
        symbolType: Symbol('k1'),
        funcType: ()=>{},
        objectType: {},
        array: [],
    };
    console.log(JSON.parse(JSON.stringify(obj)));
    /*
    {
        stringType: "123"
        numberType: 123,
        nullType: null,
        booleanType: true,
        objectType: { ... },
        array: [],
    }
    */
    
  3. bigint 會造成錯誤
    let obj = {
        bi : 123n
    };
    console.log(JSON.parse(JSON.stringify(obj)));
    // Uncaught TypeError: Do not know how to serialize a BigInt"
    
  4. 原始型別 Number 中的 Infinity, NaN 會被轉為 null
    let obj = {
        infinityNumber: Infinity,
        nanNumber:  NaN
    };
    console.log(typeof obj.infinityNumber, typeof obj.nanNumber);
    //number number
    let dcobj = JSON.parse(JSON.stringify(obj));
    console.log(typeof dcobj.infinityNumber, typeof dcobj.nanNumber);
    //object object
    console.log(dcobj);
    //{infinityNumber: null, nanNumber: null}
    
  5. 若轉換的對象有實作 toJSON 會被影響。需要轉成 string 時一樣,JSON.stringify 會優先確認該對象是否有實作 toJSON,如 Date 物件 會被轉為 string:
    let obj = {
        xmas : new Date('2024-12-25')
    }
    let dcobj = JSON.parse(JSON.stringify(obj));
    console.log(typeof obj.xmas);//"object"
    console.log(dcobj, typeof dcobj.xmas);//{xmas: "2024-12-25T00:00:00.000Z"}, "string"
    console.log(obj.xmas.toJSON);
    //ƒ (){return this.format("iso8601")}
    
  6. 若沒有實作 toJSON 情況下,複合型別只會剩下 objectarrayfunction 等三種,針對 objectarray,其實有部分情況的不同,可以和上面 2. 做比較。
    const arr = ["123", 123, null, undefined, true, Symbol('k1'), ()=>{}, {}, []];
    console.log(JSON.parse(JSON.stringify(arr)));
    //["123", 123, null, null, true, null, null, { ... }, []]
    
    在 array 中的轉換,在 object 中會消失的原始類型皆被轉為 null,如 undefined, Symbol 等等。

展開運算符

JS 中有兩種展開運算符,但看起來一樣,都是寫作 ...,分別用在物件上和陣列上。
陣列展開運算符在 ES6 (ES2015)被推出,物件展開運算符在 ES9 (ES2018)被推出。

let a = [123,456, {}];
let b = [...a];
console.log(a == b);
//false
console.log(a[2] == b[2]);
//true

let obj1 = {k1:123,k2:[]};
let obj2 = {...obj1};
console.log(obj1 == obj2);
//false
console.log(obj1.k2 == obj2.k2);
//true

在上面兩個例子,我們會看到雖然透過展開運算符做出的新物件,和原本的物件並不相等,但其實展開運算符的原理就是逐一依值和鍵放入到新的變數裡。
那在放入的過程自然就遵從原始型別與複合型別的特性:傳值或傳址,透過這樣的方式作出的複製,新舊物件本身存放於不同址,但其中的值若為複合型別,則放入的仍只是同一個位址,因此我們通常會稱作這也是一種淺拷貝。

Object.assign

另一個 ES6 引入的語法,實際上做的也是淺拷貝,用於物件的合併。
同鍵以最後出現的值覆蓋前面的。

let obj1 = {k1:123,k2:456};
let obj2 = {k2:135,k3:246};
console.log(Object.assign(obj1,obj2));
//{k1:123,k2:135,k3:246}

//那左邊放空物件是不是就能用來複製?可以
let obj3 = {k1:[],k2:123};
let obj4 = Object.assign({},obj3);
console.log(obj3.k1 == obj4.k1);
//true
//但是也是淺拷貝

那該怎麼深拷貝?

上面提的方法在複製時,其實都不是不能用,只是要提出所有需要注意的點,如 JSON 方法中的雷,如果你很確定你的物件並沒有會踩到那些雷的情況,絕對是一個很適合使用的快速方法。

如果你想要一個能夠正常複製 undefinedNaNSymbolfunction 的複製函式?肯定有,但就要用額外的三方函式庫,且要確認該複製行為是否有處理你在意的用例,如 Lodash 就是其中一個被廣泛使用的函式庫。

當然,在自己的專案有獨特需求的時候,且專案規模一定時,會建議自己編寫,撰寫深拷貝的話可以使用遞迴的方式,透過遞迴來確保走訪多層結構且無遺漏。網路上有眾多實作方法,本篇在此不特別提供範例碼,可以去網上參考並觀察各個實作有處理到的行為有哪些。


上一篇
原始型別轉換與比較
下一篇
JavaScript 的類別(Class)與物件導向(OO)
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言